-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add tags system and TMDB import integration #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
PascalRepond
commented
Jan 17, 2026
- Add Tag model with M2M relationship to Media
- Add tag filtering and search functionality
- Integrate TMDB API for importing movie/TV metadata
- Add TMDB search page with poster download support
- Refactor chip input JS to support both contributors and tags
- Update fixtures with sample tags data
📝 WalkthroughWalkthroughAdds tagging support and TMDB integration: new Tag model and M2M relation, tag-aware filtering and UI, a TMDB service client, TMDB-backed import/search views and templates, editor enhancements (chips, poster handling), and related tests and fixtures. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Browser
participant MediaImportView
participant TMDBClient
participant TMDBApi as TMDB API
participant Database
User->>Browser: Open TMDB Import page
Browser->>MediaImportView: GET /media/import/
MediaImportView->>Browser: Render media_import.html
User->>Browser: Enter search query
Browser->>MediaImportView: HTMX GET /tmdb-search/ (query, lang)
MediaImportView->>TMDBClient: search_multi(query, lang)
TMDBClient->>TMDBApi: GET /search/multi?query=...
TMDBApi-->>TMDBClient: Search results
TMDBClient-->>MediaImportView: TMDBResult list
MediaImportView->>Browser: Render tmdb_suggestions.html
sequenceDiagram
participant User
participant Browser
participant MediaEditView
participant TMDBClient
participant Database
User->>Browser: Click TMDB result (tmdb_id, media_type)
Browser->>MediaEditView: GET /media/edit/?tmdb_id=...&media_type=...
MediaEditView->>TMDBClient: get_full_details(tmdb_id, media_type)
TMDBClient->>TMDBApi: GET /{movie|tv}/{id} (+credits)
TMDBApi-->>TMDBClient: Details + credits
TMDBClient-->>MediaEditView: Full details dict
MediaEditView->>Browser: Render media_edit with prefilled data (contributors, tags, poster_url)
User->>Browser: Submit form (may include tmdb_poster_url)
Browser->>MediaEditView: POST /media/edit/
MediaEditView->>TMDBClient: download_poster(tmdb_poster_url) [if present]
TMDBClient->>TMDBApi: GET poster image
TMDBApi-->>TMDBClient: Poster bytes
MediaEditView->>Database: Save media, contributors, tags
Database-->>MediaEditView: Saved instance
MediaEditView->>Browser: Redirect to media detail
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 17
🤖 Fix all issues with AI agents
In `@docker/.env.example`:
- Around line 18-19: The .env example is missing a trailing newline which
triggers dotenv-linter; open the docker/.env.example and ensure the file ends
with a single newline after the TMDB_API_KEY=your-tmdb-api-key entry (i.e., add
a trailing newline character at the end of the file so the TMDB_API_KEY line is
terminated properly).
In `@docker/docker-compose.prod.yml`:
- Line 31: The compose file currently sets a dangerous placeholder default for
TMDB_API_KEY; change the TMDB_API_KEY environment variable in
docker-compose.prod.yml so it does not default to "your-api-key" (use no default
or an empty default like ${TMDB_API_KEY:-}) and ensure the application code that
reads TMDB_API_KEY (the TMDB client initialization path) treats a missing/empty
key as “TMDB disabled” or fails fast with a clear error; update deployment docs
or env validation to require a real TMDB_API_KEY when the feature is enabled.
In `@src/core/filters.py`:
- Around line 93-100: The apply_tag_filter function must guard against
non-numeric tag_id values to avoid ValueError from
Tag.objects.filter(pk=tag_id); wrap the lookup in a try/except catching
ValueError (and optionally TypeError) and treat invalid IDs as missing (leave
tag=None and do not alter queryset), i.e. perform the
Tag.objects.filter(pk=tag_id).first() inside that try/except and only filter the
queryset when a valid tag is returned.
In `@src/core/fixtures/sample_data.json`:
- Around line 612-649: The savedview fixture entries ("model": "core.savedview",
pk: 1 and pk: 2) reference user: 1 but no corresponding user fixture exists; add
a user fixture object for user id 1 (e.g., a "model": "core.user" entry with pk:
1 and required fields like username/email/created_at) at the top of the JSON
array or otherwise ensure a user fixture is loaded first, so the foreign key
user: 1 resolves when loading the fixtures.
In
`@src/core/migrations/0011_tag_alter_agent_options_alter_agent_name_media_tags.py`:
- Around line 30-34: Add a new data migration that runs before the
migrations.AlterField in
0011_tag_alter_agent_options_alter_agent_name_media_tags.py to find and resolve
duplicate Agent.name values so the unique=True alter will succeed; implement a
migration operation (RunPython) that queries core.Agent for duplicate names
(e.g., GROUP BY name HAVING COUNT>1), then for each duplicate group either merge
duplicates by reassigning related FK records to a single canonical Agent or
rename duplicates with a deterministic suffix (e.g., append "_1", "_2") while
preserving relations, ensure the logic is idempotent and wrapped in a
transaction, and include a reversible or no-op reverse function; reference the
Agent model and the "name" field and place this new data migration immediately
before the AlterField operation.
In `@src/core/models.py`:
- Around line 102-114: Migration 0011 is changing Agent.name to max_length=100
and unique=True but lacks a RunPython cleanup step; add a RunPython operation in
that migration that (using apps.get_model to load the Agent model) scans
existing Agent rows, truncates any name longer than 100 chars, and resolves
duplicates by appending a deterministic suffix/counter until names are unique
(or otherwise renaming in a reversible way), saving updates before the schema
change; implement both a forward cleanup function (e.g.,
clean_agent_names_forward) and a noop or reversible reverse function, and ensure
the migration runs the cleanup prior to altering the field so the
unique/max_length constraint won’t fail.
In `@src/core/services/tmdb.py`:
- Around line 198-208: The download_poster method currently fetches arbitrary
URLs (requests.get in download_poster), enabling SSRF; restrict poster_url to a
strict allowlist: validate the URL scheme is https, parse the host and only
allow known TMDB image hosts (e.g., image.tmdb.org and any documented CDN
hosts), ensure the path matches TMDB poster patterns (e.g., starts with /t/p/),
disallow any redirects by passing allow_redirects=False or validating the final
URL, and perform a DNS/IP check to reject resolved addresses in private/reserved
ranges before calling requests.get; update error logging (logger.exception) to
include the rejected URL when validation fails and return None on validation
failure.
In `@src/core/views.py`:
- Around line 47-77: The helpers _process_new_contributors and _process_new_tags
call Agent.objects.get_or_create and Tag.objects.get_or_create directly on raw
POST values, which can raise DB errors for overly long or invalid/duplicate
names; validate and guard before persistence by trimming and enforcing max
length (<=100) and non-empty, check for existing objects via
Agent.objects.filter(name=clean_name).first() /
Tag.objects.filter(name=clean_name).first() to avoid unique constraint races,
and wrap get_or_create in a try/except catching IntegrityError (and optionally
ProgrammingError) to convert DB errors into a controlled ValidationError or
returnable form error so the view returns a proper user-facing error instead of
a 500.
In `@src/static/js/base.js`:
- Around line 153-195: The multi-select label text in initMultiSelectLabels uses
hardcoded English strings; change it to read localized strings from data
attributes on the label element (e.g., data-default-text and data-selected-text)
and use those instead of "All types"/"All statuses"/"All scores" and "selected";
update the code paths in initMultiSelectLabels where filter.defaultText and the
`${checkedCount} selected` string are set to use label.dataset.defaultText and
label.dataset.selectedText (falling back to current defaults if attributes are
missing), and ensure the template renders those data attributes using {%
translate %}.
In `@src/static/js/media_edit.js`:
- Around line 24-29: Update the duplicate-detection logic so chips are compared
by visible name as well as id: change chipExists to query
container.querySelectorAll('.badge') and for each badge derive the normalized
name by checking badge.dataset.name, then
badge.querySelector('span')?.textContent, then badge.textContent, trimming and
lowercasing before comparing to the input name; and in the HTMX response handler
(the code that currently only checks data-id before inserting a server-rendered
chip) call this updated chipExists(name) as an additional guard and skip
insertion if a name match exists to prevent duplicates between manually entered
chips and server-rendered chips.
In `@src/templates/base/media_edit.html`:
- Around line 65-84: The hidden input tmdb_poster_url is always submitted and
can override an uploaded file; update the client-side behavior so when the cover
file input (rendered as {{ form.cover }}) receives a file selection you clear or
remove the hidden input with name "tmdb_poster_url" (or set its value to empty)
and update the preview (element id "tmdb-poster-preview") accordingly, and also
on form submit ensure you check the cover file input and prevent submitting
tmdb_poster_url if a file is present; implement this logic in
src/static/js/media_edit.js and wire it into the template so uploads always take
precedence over the TMDB poster.
In `@src/templates/base/media_import.html`:
- Around line 9-12: The back/manual link currently always uses the 'media_add'
URL and ignores the media_id context; update the anchor(s) that call {% url
'media_add' %} (and the identical links at lines 58-61) to conditionally use the
edit route when media_id is present (e.g., {% if media_id %}{% url 'media_edit'
media_id %}{% else %}{% url 'media_add' %}{% endif %}) so the link points to the
existing media edit screen when importing into an existing media.
In `@src/templates/partials/filters/filter_badge.html`:
- Around line 12-15: The remove button (the button element with class
"remove-filter-badge" that renders the lucide "x" icon) is icon-only and needs
an accessible name; add an aria-label (and optionally a title) that includes the
filter context—e.g. when filter_name or filter_value is present use a
descriptive label like "Remove {filter_name} filter" or "Remove filter
{filter_value}", otherwise fall back to "Remove filter"—implement this as
conditional attributes in the same template so screen readers get a meaningful
label while preserving existing data-filter and data-value attributes.
In `@src/templates/partials/media_items/media_item.html`:
- Around line 34-36: The media template iterates media.tags.all causing N+1
queries; update build_media_context in src/core/queries.py to add
prefetch_related("tags") to the media querysets: change the Media.objects.all()
call used for default queries to Media.objects.all().prefetch_related("tags")
and the filtered query Media.objects.filter(q_objects) to
Media.objects.filter(q_objects).prefetch_related("tags").distinct() so tags are
prefetched for each media item.
In `@src/templates/partials/tags/tag_chip.html`:
- Around line 3-6: The remove-chip button currently only exposes a glyph to
assistive tech; update the button element with an accessible label by adding an
aria-label that includes the tag name (e.g., aria-label="Remove tag {{ tag.name
}}") and optionally a title attribute for hover/tooltips, targeting the button
with data-action="remove-chip" in the tag_chip template so screen readers
announce the action clearly.
In `@src/templates/partials/tmdb/tmdb_suggestions.html`:
- Around line 35-37: The template currently checks only that
result.original_title != result.title, which can render "(None)" when
original_title is null/empty; update the condition to also guard for a non-empty
original_title (e.g., check that result.original_title is truthy) before
rendering the span so that you render the parentheses only when original_title
exists and differs from result.title; locate the conditional around
result.original_title and result.title in the tmdb suggestions partial and
modify it to require result.original_title to be present and non-empty.
In `@src/tests/core/test_tmdb.py`:
- Around line 67-72: The test test_raises_error_without_api_key is
non-deterministic because passing api_key="" allows fallback to
settings.TMDB_API_KEY; update the test to accept pytest-django's settings
fixture, explicitly set settings.TMDB_API_KEY = "" (or None) inside the test,
then instantiate TMDBClient() with no arguments and assert TMDBError is raised
and contains "TMDB API key is required"; reference TMDBClient and TMDBError in
the test so the behavior is deterministic regardless of environment variables.
src/core/migrations/0011_tag_alter_agent_options_alter_agent_name_media_tags.py
Show resolved
Hide resolved
- Add Tag model with M2M relationship to Media - Add tag filtering and search functionality - Integrate TMDB API for importing movie/TV metadata - Add TMDB search page with poster download support - Refactor chip input JS to support both contributors and tags - Update fixtures with sample tags data
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@src/core/services/tmdb.py`:
- Around line 104-106: Trim the incoming query before the empty/length guard so
whitespace-only strings are treated as empty: assign a trimmed value (e.g.,
query = query.strip() or use a separate trimmed_query) and then check if not
query or len(query) < MIN_QUERY_LENGTH to return []; ensure you handle None
safely (only call strip() when query is not None) and update the existing guard
that currently references query and MIN_QUERY_LENGTH.
- Around line 168-184: The list comprehensions that extract names (e.g.,
directors = [p["name"] for p in crew if p.get("job") == "Director"], directors =
[p["name"] for p in data.get("created_by", [])], production_companies =
[c["name"] for c in data.get("production_companies", [])[:2]], genres =
[g["name"] for g in data.get("genres", [])]) can raise KeyError when TMDB
returns partial objects; change these to use .get("name") and filter out falsy
values (e.g., [p.get("name") for p in crew if p.get("job") == "Director" and
p.get("name")]) so missing name fields are ignored rather than crashing, and
apply the same pattern to created_by, production_companies, and genres.
- Around line 71-90: In TMDB._request, stop building full_url manually and call
requests.get(url, params=params, timeout=10); keep response.raise_for_status(),
but wrap response.json() in its own try/except to catch
JSONDecodeError/ValueError, log the response.status_code, response.text (body)
and the params for context using logger.exception or logger.error, then re-raise
a TMDBError (or the original exception) so callers get a clear error; also
expand the outer requests.RequestException except to include logging the params
and endpoint before re-raising. Use the TMDB._request method name and the
params/response variables to locate where to make these changes.
🧹 Nitpick comments (1)
src/core/filters.py (1)
39-48: Consider reflecting tag filters inhas_any.
Ifhas_anydrives “filters active” UI, tag-only filters will currently read as inactive.♻️ Possible tweak
- filters["has_any"] = any( - [ - filters["type"], - filters["status"], - filters["score"], - filters["review_from"], - filters["review_to"], - filters["has_review"], - filters["has_cover"], - ] - ) + filters["has_any"] = any( + [ + filters["type"], + filters["status"], + filters["score"], + filters["review_from"], + filters["review_to"], + filters["has_review"], + filters["has_cover"], + filters["contributor"], + filters["tag"], + ] + )
| def __init__(self, api_key: str | None = None): | ||
| self.api_key = api_key or settings.TMDB_API_KEY | ||
| if not self.api_key: | ||
| raise TMDBError | ||
|
|
||
| def _request(self, endpoint: str, params: dict | None = None) -> dict: | ||
| """Make a request to the TMDB API.""" | ||
| params = params or {} | ||
| params["api_key"] = self.api_key | ||
|
|
||
| url = urljoin(TMDB_BASE_URL, endpoint) | ||
| full_url = f"{url}?{urlencode(params)}" | ||
|
|
||
| try: | ||
| response = requests.get(full_url, timeout=10) | ||
| response.raise_for_status() | ||
| return response.json() | ||
| except requests.RequestException: | ||
| logger.exception("TMDB API request failed") | ||
| raise |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's check if the file exists and read the relevant section
if [ -f "src/core/services/tmdb.py" ]; then
echo "File found. Reading lines 60-100 for context:"
sed -n '60,100p' src/core/services/tmdb.py | cat -n
else
echo "File not found at src/core/services/tmdb.py"
find . -name "tmdb.py" -type f 2>/dev/null | head -5
fiRepository: PascalRepond/datakult
Length of output: 1761
🏁 Script executed:
# Also check the imports to understand the dependencies
head -30 src/core/services/tmdb.py 2>/dev/null | cat -nRepository: PascalRepond/datakult
Length of output: 1133
🌐 Web query:
requests library response.json() exception behavior
💡 Result:
Short answer
- Response.json() will raise an error if the body is empty or not valid JSON. What exact exception you get depends on which JSON library is used: either the stdlib json.JSONDecodeError (subclass of ValueError) or simplejson.errors.JSONDecodeError (if simplejson is installed). The Requests docs describe this behavior. [1]
What to catch (practical)
- To robustly handle all cases, catch json.JSONDecodeError and ValueError (covers stdlib), and, if you support environments with simplejson, also catch simplejson.errors.JSONDecodeError (or just catch ValueError as a fallback). Example pattern:
try:
obj = response.json()
except (json.JSONDecodeError, ValueError):
# handle non-JSON / empty body
...
References - Requests documentation (r.json() behavior and exceptions). [1]
- GitHub issue discussing inconsistent exception types when simplejson is present. [2]
- Stack Overflow examples showing JSONDecodeError from response.json() on empty/non-JSON responses. [3]
Sources
[1] Requests docs — JSON response content / errors. (dokk.org)
[2] psf/requests issue: "response.json() raises inconsistent exception type". (github.com)
[3] Stack Overflow: JSONDecodeError when calling r.json() on empty/non-JSON responses. (stackoverflow.com)
Citations:
- 1: https://dokk.org/documentation/python-requests/v2.26.0/user/quickstart/?utm_source=openai
- 2: response.json() raises inconsistent exception type psf/requests#5794
- 3: https://stackoverflow.com/questions/16573332/jsondecodeerror-expecting-value-line-1-column-1-char-0?utm_source=openai
Handle non-JSON TMDB responses and log context.
response.json() can raise JSONDecodeError (e.g., when TMDB returns HTML error pages) and currently bypasses logging. Catch the exception and log response status/body for debugging. Additionally, pass params directly to requests.get() instead of manual URL construction.
🛠️ Proposed fix
- url = urljoin(TMDB_BASE_URL, endpoint)
- full_url = f"{url}?{urlencode(params)}"
+ url = urljoin(TMDB_BASE_URL, endpoint)
try:
- response = requests.get(full_url, timeout=10)
+ response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
- return response.json()
+ try:
+ return response.json()
+ except ValueError:
+ logger.exception(
+ "TMDB API response was not valid JSON (status=%s)",
+ response.status_code,
+ )
+ raise
except requests.RequestException:
logger.exception("TMDB API request failed")
raise📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def __init__(self, api_key: str | None = None): | |
| self.api_key = api_key or settings.TMDB_API_KEY | |
| if not self.api_key: | |
| raise TMDBError | |
| def _request(self, endpoint: str, params: dict | None = None) -> dict: | |
| """Make a request to the TMDB API.""" | |
| params = params or {} | |
| params["api_key"] = self.api_key | |
| url = urljoin(TMDB_BASE_URL, endpoint) | |
| full_url = f"{url}?{urlencode(params)}" | |
| try: | |
| response = requests.get(full_url, timeout=10) | |
| response.raise_for_status() | |
| return response.json() | |
| except requests.RequestException: | |
| logger.exception("TMDB API request failed") | |
| raise | |
| def __init__(self, api_key: str | None = None): | |
| self.api_key = api_key or settings.TMDB_API_KEY | |
| if not self.api_key: | |
| raise TMDBError | |
| def _request(self, endpoint: str, params: dict | None = None) -> dict: | |
| """Make a request to the TMDB API.""" | |
| params = params or {} | |
| params["api_key"] = self.api_key | |
| url = urljoin(TMDB_BASE_URL, endpoint) | |
| try: | |
| response = requests.get(url, params=params, timeout=10) | |
| response.raise_for_status() | |
| try: | |
| return response.json() | |
| except ValueError: | |
| logger.exception( | |
| "TMDB API response was not valid JSON (status=%s)", | |
| response.status_code, | |
| ) | |
| raise | |
| except requests.RequestException: | |
| logger.exception("TMDB API request failed") | |
| raise |
🤖 Prompt for AI Agents
In `@src/core/services/tmdb.py` around lines 71 - 90, In TMDB._request, stop
building full_url manually and call requests.get(url, params=params,
timeout=10); keep response.raise_for_status(), but wrap response.json() in its
own try/except to catch JSONDecodeError/ValueError, log the
response.status_code, response.text (body) and the params for context using
logger.exception or logger.error, then re-raise a TMDBError (or the original
exception) so callers get a clear error; also expand the outer
requests.RequestException except to include logging the params and endpoint
before re-raising. Use the TMDB._request method name and the params/response
variables to locate where to make these changes.
| if not query or len(query) < MIN_QUERY_LENGTH: | ||
| return [] | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trim queries before the length check.
Whitespace-only queries currently pass the guard and still hit TMDB.
🛠️ Proposed fix
- if not query or len(query) < MIN_QUERY_LENGTH:
+ query = query.strip()
+ if not query or len(query) < MIN_QUERY_LENGTH:
return []📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if not query or len(query) < MIN_QUERY_LENGTH: | |
| return [] | |
| query = query.strip() | |
| if not query or len(query) < MIN_QUERY_LENGTH: | |
| return [] | |
🤖 Prompt for AI Agents
In `@src/core/services/tmdb.py` around lines 104 - 106, Trim the incoming query
before the empty/length guard so whitespace-only strings are treated as empty:
assign a trimmed value (e.g., query = query.strip() or use a separate
trimmed_query) and then check if not query or len(query) < MIN_QUERY_LENGTH to
return []; ensure you handle None safely (only call strip() when query is not
None) and update the existing guard that currently references query and
MIN_QUERY_LENGTH.
| crew = data.get("credits", {}).get("crew", []) | ||
| directors = [p["name"] for p in crew if p.get("job") == "Director"] | ||
| else: | ||
| data = self.get_tv_details(tmdb_id, language) | ||
| title = data.get("name", "") | ||
| original_title = data.get("original_name", "") | ||
| date_field = data.get("first_air_date", "") | ||
| # For TV shows, get creators instead of directors | ||
| directors = [p["name"] for p in data.get("created_by", [])] | ||
|
|
||
| year = int(date_field[:4]) if date_field and len(date_field) >= MIN_DATE_LENGTH else None | ||
|
|
||
| # Get production companies | ||
| production_companies = [c["name"] for c in data.get("production_companies", [])[:2]] | ||
|
|
||
| # Get genres | ||
| genres = [g["name"] for g in data.get("genres", [])] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid KeyError on partial TMDB payloads.
Some objects may omit name; using .get() with filtering avoids hard failures.
🛠️ Proposed fix
- directors = [p["name"] for p in crew if p.get("job") == "Director"]
+ directors = [
+ p.get("name")
+ for p in crew
+ if p.get("job") == "Director" and p.get("name")
+ ]
@@
- directors = [p["name"] for p in data.get("created_by", [])]
+ directors = [p.get("name") for p in data.get("created_by", []) if p.get("name")]
@@
- production_companies = [c["name"] for c in data.get("production_companies", [])[:2]]
+ production_companies = [
+ c.get("name")
+ for c in data.get("production_companies", [])[:2]
+ if c.get("name")
+ ]
@@
- genres = [g["name"] for g in data.get("genres", [])]
+ genres = [g.get("name") for g in data.get("genres", []) if g.get("name")]🤖 Prompt for AI Agents
In `@src/core/services/tmdb.py` around lines 168 - 184, The list comprehensions
that extract names (e.g., directors = [p["name"] for p in crew if p.get("job")
== "Director"], directors = [p["name"] for p in data.get("created_by", [])],
production_companies = [c["name"] for c in data.get("production_companies",
[])[:2]], genres = [g["name"] for g in data.get("genres", [])]) can raise
KeyError when TMDB returns partial objects; change these to use .get("name") and
filter out falsy values (e.g., [p.get("name") for p in crew if p.get("job") ==
"Director" and p.get("name")]) so missing name fields are ignored rather than
crashing, and apply the same pattern to created_by, production_companies, and
genres.